В моем распоряжении есть датасет с действиями пользователей и несколько вспомогательных датасетов. Задача состоит в проведении оценки результатов A/B-теста.
Необходимо:
Дополнительно проверить:
План работ:
Техническое задание:
Название теста: recommender_system_test;
Мой основной инструмент — pandas. Я подключаю эту библиотеку. Также подключаю библиотеки datetime, numpy, matplotlib, scipy. Они потребуются для проведения моего исследования.
Дополнительно отключу предупреждения (библиотека warnings).
import pandas as pd
import datetime as dt
import numpy as np
import matplotlib.pyplot as plt
import scipy.stats as stats
from datetime import timedelta
import seaborn as sns
from plotly import graph_objects as go
import plotly.express as px
import math as mth
from scipy import stats as st
# конвертеры, которые позволяют использовать типы pandas в matplotlib
from pandas.plotting import register_matplotlib_converters
import warnings as wg # импорт библиотеки warnings
wg.filterwarnings('ignore')
register_matplotlib_converters()
# чтение файлов с данными и сохранение в переменные в зависимости от расположения
# локальный путь
try:
marketing_events = pd.read_csv('/Users/a4128/Documents/My_projects/13_Final/ab_project_marketing_events.csv')
users = pd.read_csv('/Users/a4128/Documents/My_projects/13_Final/final_ab_new_users.csv')
events = pd.read_csv('/Users/a4128/Documents/My_projects/13_Final/final_ab_events.csv')
test = pd.read_csv('/Users/a4128/Documents/My_projects/13_Final/final_ab_participants.csv')
# серверный путь
except:
marketing_events = pd.read_csv('/datasets/ab_project_marketing_events.csv')
users = pd.read_csv('/datasets/final_ab_new_users.csv')
events = pd.read_csv('/datasets/final_ab_events.csv')
test = pd.read_csv('/datasets/final_ab_participants.csv')
data = [marketing_events, users, events, test]
for element in data:
display(element.head())
element.info()
print('Обнаружено явных дубликатов:', element.duplicated().sum())
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 3 | Easter Promo | EU, CIS, APAC, N.America | 2020-04-12 | 2020-04-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes Обнаружено явных дубликатов: 0
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
| 3 | 50734A22C0C63768 | 2020-12-07 | EU | iPhone |
| 4 | E1BDDCE0DAFA2679 | 2020-12-07 | N.America | iPhone |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB Обнаружено явных дубликатов: 0
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB Обнаружено явных дубликатов: 0
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
| 3 | 04988C5DF189632E | A | recommender_system_test |
| 4 | 482F14783456D21B | B | recommender_system_test |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB Обнаружено явных дубликатов: 0
Для последующего удобства присвою имена датафреймам, с которыми мне предстоит работать:
marketing_events.name = 'marketing_events'
users.name = 'users'
events.name = 'events'
test.name = 'test'
events['details'].isna().sum()
print('Доля пропусков составляет:', round(events['details'].isna().sum() / len(events), 2))
Доля пропусков составляет: 0.86
Т.к. в данных обнаружено около 86% пропущенных значений, то обработке такое огромное количество строк не подлежит. Наличие пропусков, очевидно, связано с необязательностью заполнения данного поля при вводе данных (дополнительные данные о событии - это опционально заполняемая информация).
change_date = ['start_dt', 'finish_dt']
for element in change_date:
print(element, '- исходный тип данных:', marketing_events[element].dtypes)
marketing_events[element] = marketing_events[element].map(
lambda x: dt.datetime.strptime(x, '%Y-%m-%d')
)
print('Тип данных после преобразования:', marketing_events[element].dtypes)
print()
print('first_date - исходный тип данных:', users['first_date'].dtypes)
users['first_date'] = users['first_date'].map(
lambda x: dt.datetime.strptime(x, '%Y-%m-%d')
)
print('Тип данных после преобразования:', users['first_date'].dtypes)
print()
print('event_dt - исходный тип данных:', events['event_dt'].dtypes)
events['event_dt'] = events['event_dt'].map(
lambda x: dt.datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
)
print('Тип данных после преобразования:', events['event_dt'].dtypes)
start_dt - исходный тип данных: object Тип данных после преобразования: datetime64[ns] finish_dt - исходный тип данных: object Тип данных после преобразования: datetime64[ns] first_date - исходный тип данных: object Тип данных после преобразования: datetime64[ns] event_dt - исходный тип данных: object Тип данных после преобразования: datetime64[ns]
Разделю дату в столбце event_dt на дату и временя отдельно, так будет целесообразнее для последующего анализа.
events['date'] = events['event_dt'].dt.date
events['time'] = events['event_dt'].dt.time
events.head()
| user_id | event_dt | event_name | details | date | time | |
|---|---|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 | 2020-12-07 | 20:22:03 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 | 2020-12-07 | 09:22:53 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 | 2020-12-07 | 12:59:29 |
| 3 | 96F27A054B191457 | 2020-12-07 04:02:40 | purchase | 4.99 | 2020-12-07 | 04:02:40 |
| 4 | 1FD7660FDF94CA1F | 2020-12-07 10:15:09 | purchase | 4.99 | 2020-12-07 | 10:15:09 |
events['date'] = pd.to_datetime(events['date'], format ='%Y-%m-%d')#.dt.date
events['time'] = pd.to_datetime(events['time'], format ='%H:%M:%S')#.dt.time
#display(events.head())
print(events[['date', 'time']].dtypes)
date datetime64[ns] time datetime64[ns] dtype: object
Проверю количество уникальных пользователей в таблицах:
users_count = [users, events, test]
for element in users_count:
print('Количество уникальных пользователей в таблице:', element.name, '-', element['user_id'].nunique())
print()
Количество уникальных пользователей в таблице: users - 61733 Количество уникальных пользователей в таблице: events - 58703 Количество уникальных пользователей в таблице: test - 16666
Можно увидеть, что количество уникальных пользователей в таблицах различается, т.к. не все пользователи участуют в тестировании, а также не все пользователи совершают какие-то события.
print('Уникальные события:')
events['event_name'].value_counts()
Уникальные события:
login 189552 product_page 125563 purchase 62740 product_cart 62462 Name: event_name, dtype: int64
events_all = len(events)
print('Всего в логе событий:', events_all)
Всего в логе событий: 440317
# Проверка
events_unique = (events
.groupby(['user_id', 'event_dt', 'event_name'])['event_name']
.count()
.sort_values(ascending=False)
)
print('Всего в логе уникальных событий:', len(events_unique))
Всего в логе уникальных событий: 440317
users_all = events['user_id'].nunique() # всего количество уникальных пользователей
print('Всего пользователей в логе:', users_all)
Всего пользователей в логе: 58703
print('В среднем на одного пользователя приходится событий:', round(len(events) / events['user_id'].nunique()))
В среднем на одного пользователя приходится событий: 8
Проверю полноту данных по исследуемым датам, построив распределение событий по времени.
print(events['date'].value_counts())
events['date'].value_counts().plot(color='violet', figsize=(15, 6), grid=True, linewidth=4)
plt.title('Распределение количества событий по времени', fontsize=15)
plt.xlabel('Дата', fontsize=15, color='grey')
plt.ylabel('Количество событий', fontsize=15, color='grey');
2020-12-21 32559 2020-12-22 29472 2020-12-20 26425 2020-12-14 26184 2020-12-23 26108 2020-12-19 24273 2020-12-15 23469 2020-12-18 22871 2020-12-17 21751 2020-12-13 20985 2020-12-16 20909 2020-12-24 19399 2020-12-12 17634 2020-12-25 16556 2020-12-10 14077 2020-12-26 14058 2020-12-11 13864 2020-12-08 12547 2020-12-27 12420 2020-12-09 12122 2020-12-07 11385 2020-12-28 11014 2020-12-29 10146 2020-12-30 89 Name: date, dtype: int64
Можно обратить внимание, что данные за 30 декабря неполные, нужно обратить на эту дату особое внимание.
Итак, данные получены, отображены и предварительно пранализированы.
Согласно документации, структуда данных следующая:
календарь маркетинговых событий на 2020 год marketing_events:
name — название маркетингового события;regions — регионы, в которых будет проводиться рекламная кампания;start_dt — дата начала кампании;finish_dt — дата завершения кампании.Все пользователи, зарегистрировавшиеся в интернет-магазине в период с 7 по 21 декабря 2020 года users:
user_id — идентификатор пользователя;first_date — дата регистрации;region — регион пользователя;device — устройство, с которого происходила регистрация.Все события новых пользователей в период с 7 декабря 2020 по 4 января 2021 года events:
user_id — идентификатор пользователя;event_dt — дата и время события;event_name — тип события;details — дополнительные данные о событии. Например, для покупок, purchase, в этом поле хранится стоимость покупки в долларах.Таблица участников тестов participants:
user_id — идентификатор пользователя;ab_test — название теста;group — группа пользователя.На этапе предварительного анализа выявлено:
details таблицы events (количество значений в столбце различается);Проведено:
Сведения о событиях:
test['ab_test'].value_counts()
interface_eu_test 11567 recommender_system_test 6701 Name: ab_test, dtype: int64
Можно увидеть, что в таблице содержатся результаты 2-ух проведенных тестов, а техническое задание предусматривает анализ теста recommender_system_test, поэтому необходимо исключить данные об участниках стороннего теста, предварительно проверив их участие в обоих тестах. Т.к. участие одного и того же пользователя ещё в одном тесте может исказить результаты.
users_tests = test.groupby('user_id').agg({'ab_test': ['nunique', 'unique']})
users_tests.columns = ['tests', 'test_name']
print('Кол-во пользователей, участвоваших в обоих тестах -', len(users_tests.query('tests > 1')))
Кол-во пользователей, участвоваших в обоих тестах - 1602
Обнаружено 1602 человека, участвовашших в обоих тестах, однако исключению подлежат только те их них, кто состоит в группе B, т.к. пользоватлеи из группы А (контрольной) не взаимодействовали с изменениями.
group_1 = users_tests.loc[users_tests['tests'] == 1]['tests'].reset_index()
group_2 = users_tests.loc[users_tests['tests'] == 2]['tests'].reset_index()
test = test.query('ab_test == "recommender_system_test"')
test = test.loc[
(test['user_id'].isin(group_1['user_id'])) |
((test['user_id'].isin(group_2['user_id'])) & (test['group'] == 'B'))
]
print('Количество участников исследуемого теста:', len(test))
Количество участников исследуемого теста: 5780
Необходимо обратить внимание, что при исключении учстников стороннего теста и пользователей, принимающих участие в двух тестах одновременно (из контрольной группы А), я получаю количество участников теста: 5780, тогда как в техническом задании ожидаемое количество участников теста составляет 6000 пользователей.
print(test['group'].value_counts())
(
test.groupby('group')['user_id']
.nunique()
.plot.pie(autopct='%1.2f%%', colors = ['violet', 'pink'], figsize=(6, 6))
)
plt.title('Распределение пользователей по группам тестирования')
plt.ylabel('Номер группы', color='grey');
A 2903 B 2877 Name: group, dtype: int64
test_all_a = 2903
test_all_b = 2877
users_groups = test.groupby('user_id').agg({'group': ['nunique', 'unique']})
users_groups.columns = ['groups', 'group_name']
print('Кол-во пользователей, попавших в обе группы -', len(users_groups.query('groups > 1')))
print('Кол-во уникальных пользователей, участвующих в тестировании', test['user_id'].nunique())
Кол-во пользователей, попавших в обе группы - 0 Кол-во уникальных пользователей, участвующих в тестировании 5780
Исходя из этих результатов, можно можно сделать вывод, что тестовые группы сформированы правильно.
Проверю распределение тестовой аудитории по регионам:
users_test = users.loc[users['user_id'].isin(test['user_id'])]
print('Доля пользователей из EU:', round(users_test.query('region == "EU"')['user_id'].nunique() /
users.query('region == "EU"')['user_id'].nunique(), 2))
Доля пользователей из EU: 0.12
Необходимо обратить внимание, что доля новых пользователей из региона EU составляет около 12%, тогда как в техническом задании это значение составляет 15%.
Проверю, является ли эта разница статистически значимой для проведения теста. Т.к. я буду проверять гипотезу о равенстве долей, то я буду использовать z-тест.
Напишу функцию с необходимыми параметрами для использования в дальнейшей работе:
def z_test(alpha, total_1, total_2, part_1, part_2):
alpha = alpha
product = np.array([part_1, part_2])
visit = np.array([total_1, total_2])
p1 = product[0]/visit[0]
p2 = product[1]/visit[1]
p_combined = (product[0] + product[1]) / (visit[0] + visit[1])
difference = p1 - p2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/visit[0] + 1/visit[1]))
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
Расчитаю количество новых пользователей из региона EU в тестовой выборке:
print('Расчитаю количество новых пользователей из региона EU в тестовой выборке:', users_test.query('region == "EU"')['user_id'].nunique())
print('Всего новых пользователей из региона EU:', users.query('region == "EU"')['user_id'].nunique())
print('Необходимое количество новых пользователей из региона EU:', round(users.query('region == "EU"')['user_id'].nunique() * 0.15))
Расчитаю количество новых пользователей из региона EU в тестовой выборке: 5430 Всего новых пользователей из региона EU: 46270 Необходимое количество новых пользователей из региона EU: 6940
Нулевая гипотеза H₀:
Альтернативная гипотеза H₁:
Приму стандартное значение критического уровня статистической значимости в 5% и определю, является ли различие 15% и 12% статистически значимым.
z_test(0.05, 46270, 46270, 5430, 6940)
p-значение: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница
# Проверка
from statsmodels.stats.proportion import proportions_ztest
count = np.array([5430])
nobs = np.array([46270])
value = .15
stat, pval = proportions_ztest(count, nobs, value)
print(pval)
Доля новых пользователей из региона EU составляет около 12%, тогда как в техническом задании это значение составляет 15%. И эта разница является статистически значимой, что подтверждено проведенным z-тестом.
print('Минимальная дата регистрации новых пользователей:', users_test['first_date'].min())
print('Максимальная дата:', users_test['first_date'].max())
Минимальная дата регистрации новых пользователей: 2020-12-07 00:00:00 Максимальная дата: 2020-12-21 00:00:00
Временной интервал начала и остановки набора новых пользователей соответствует техническому заданию.
events_test = events.loc[events['user_id'].isin(test['user_id'])]
print('Минимальная дата событий новых пользователей:', events_test['date'].min())
print('Максимальная дата:', events_test['date'].max())
Минимальная дата событий новых пользователей: 2020-12-07 00:00:00 Максимальная дата: 2020-12-30 00:00:00
Необходимо обратить внимание, что в описании к данным указан временной диапазон всех событий новых пользователей в период с 7 декабря 2020 по 4 января 2021, однако представленные данные содержат информацию только до 2020-12-30, что не соответствует техническому заданию.
Проверю, не совпадает ли время теста с маркетинговыми и другими активностями. Даты проведения теста: 07/12/2020-01/04/2020.
start = '2020-12-07'
end = '2021-01-04'
marketing_events.query('@start <= start_dt <= @end or @start <= finish_dt <= @end')
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | marketing_events | EU, N.America | 2020-12-25 | 2021-01-03 |
| 10 | marketing_events | CIS | 2020-12-30 | 2021-01-07 |
Обнаружено, что во время проведения АВ-теста проходило 2 промо-кампании: рождественская и новогодняя.
В техническом задании указано, что ожидаемый эффект нужно наблюдать в течение 14 дней с момента регистрации в системе. Необходимо проверить датафрейм тестовых событий и исключить те события, которые произошли более, чем через 14 день после регистрации.
Сформирую оющую таблицу событий, припецепив к таблице событий информацию по проведенным тестам и пользователям:
final = pd.merge(events_test, test, on=['user_id', 'user_id']).drop('ab_test', axis=1)
Добавлю в итоговую таблицу информацию о пользователях:
final = pd.merge(final, users_test, how='left', on=['user_id', 'user_id'])
final = final.rename(columns={'date': 'event_date',
'time': 'event_time',
'first_date': 'reg_date'})
len(final)
20131
Оставляю в финальной тестовой таблице только те записи, в которых собитие наступило не позднее 14 дней с момента регистрации пользователя.
delta = timedelta(days=14)
final = final.query('event_date - reg_date <= @delta').reset_index(drop=True)
final
| user_id | event_dt | event_name | details | event_date | event_time | group | reg_date | region | device | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 831887FE7F2D6CBA | 2020-12-07 06:50:29 | purchase | 4.99 | 2020-12-07 | 1900-01-01 06:50:29 | A | 2020-12-07 | EU | Android |
| 1 | 831887FE7F2D6CBA | 2020-12-09 02:19:17 | purchase | 99.99 | 2020-12-09 | 1900-01-01 02:19:17 | A | 2020-12-07 | EU | Android |
| 2 | 831887FE7F2D6CBA | 2020-12-07 06:50:30 | product_cart | NaN | 2020-12-07 | 1900-01-01 06:50:30 | A | 2020-12-07 | EU | Android |
| 3 | 831887FE7F2D6CBA | 2020-12-08 10:52:27 | product_cart | NaN | 2020-12-08 | 1900-01-01 10:52:27 | A | 2020-12-07 | EU | Android |
| 4 | 831887FE7F2D6CBA | 2020-12-09 02:19:17 | product_cart | NaN | 2020-12-09 | 1900-01-01 02:19:17 | A | 2020-12-07 | EU | Android |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 19577 | 1E6B9298415AA97A | 2020-12-22 03:22:34 | login | NaN | 2020-12-22 | 1900-01-01 03:22:34 | B | 2020-12-21 | N.America | Android |
| 19578 | 1E6B9298415AA97A | 2020-12-23 11:34:53 | login | NaN | 2020-12-23 | 1900-01-01 11:34:53 | B | 2020-12-21 | N.America | Android |
| 19579 | 1E6B9298415AA97A | 2020-12-24 18:45:58 | login | NaN | 2020-12-24 | 1900-01-01 18:45:58 | B | 2020-12-21 | N.America | Android |
| 19580 | 1E6B9298415AA97A | 2020-12-25 16:40:01 | login | NaN | 2020-12-25 | 1900-01-01 16:40:01 | B | 2020-12-21 | N.America | Android |
| 19581 | 23DDD27AC3FEFA63 | 2020-12-21 02:51:45 | login | NaN | 2020-12-21 | 1900-01-01 02:51:45 | A | 2020-12-21 | EU | PC |
19582 rows × 10 columns
print('Количество уникальных пользователей, совершивших события из тестовой выборки:', final['user_id'].nunique())
print('Это ', round(final['user_id'].nunique() / test['user_id'].nunique() * 100, 2), '%.', sep='')
Количество уникальных пользователей, совершивших события из тестовой выборки: 3010 Это 52.08%.
Следует обратить внимание, что из 5780 участников теста, события совершали только 3010 - чуть больше половины всех пользователей, участвующих в проведении теста.
final.head()
| user_id | event_dt | event_name | details | event_date | event_time | group | reg_date | region | device | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 831887FE7F2D6CBA | 2020-12-07 06:50:29 | purchase | 4.99 | 2020-12-07 | 1900-01-01 06:50:29 | A | 2020-12-07 | EU | Android |
| 1 | 831887FE7F2D6CBA | 2020-12-09 02:19:17 | purchase | 99.99 | 2020-12-09 | 1900-01-01 02:19:17 | A | 2020-12-07 | EU | Android |
| 2 | 831887FE7F2D6CBA | 2020-12-07 06:50:30 | product_cart | NaN | 2020-12-07 | 1900-01-01 06:50:30 | A | 2020-12-07 | EU | Android |
| 3 | 831887FE7F2D6CBA | 2020-12-08 10:52:27 | product_cart | NaN | 2020-12-08 | 1900-01-01 10:52:27 | A | 2020-12-07 | EU | Android |
| 4 | 831887FE7F2D6CBA | 2020-12-09 02:19:17 | product_cart | NaN | 2020-12-09 | 1900-01-01 02:19:17 | A | 2020-12-07 | EU | Android |
report = final['event_name'].value_counts().sort_values(ascending=False)
print(report)
report.plot(linewidth=2, grid=True, marker='o', figsize=(10,5))
plt.title('Распределение количества пользователей по шагам', fontsize=15)
plt.ylabel('Количество событий', fontsize=13, color='grey')
plt.xlabel('Событие', fontsize=13, color='grey')
plt.xticks(rotation=20)
plt.show();
login 8888 product_page 5494 product_cart 2619 purchase 2581 Name: event_name, dtype: int64
В логе представлена информация по 5 событиям:
Из полученных данных и графика можно увидеть, что чаще пользователи логинятся, следующим этапом просматривают карточку товара, корзину и на заключительном этапе совершают покупку.
Разделю датафрейм на 2 тестовые группы для удобства работы.
test_a = final.query('group == "A"')
test_b = final.query('group == "B"')
result_a = (test_a
.groupby('event_name')
.agg({'event_name': 'count', 'user_id': 'nunique'})
)
result_a['conv %'] = round(result_a['user_id'] / test_a['user_id'].nunique() * 100, 2)
#result_a = result_a.sort_values(by='conv %', ascending=False)
result_a = result_a.rename(columns={'event_name': 'events', 'user_id': 'users'})
result_b = (test_b
.groupby('event_name')
.agg({'event_name': 'count', 'user_id': 'nunique'})
)
result_b['conv %'] = round(result_b['user_id'] / test_b['user_id'].nunique() * 100, 2)
#result_b = result_b.sort_values(by='conv %', ascending=False)
result_b = result_b.rename(columns={'event_name': 'events', 'user_id': 'users'})
display(result_a, result_b)
| events | users | conv % | |
|---|---|---|---|
| event_name | |||
| login | 6395 | 2082 | 100.00 |
| product_cart | 1960 | 631 | 30.31 |
| product_page | 4163 | 1360 | 65.32 |
| purchase | 1941 | 652 | 31.32 |
| events | users | conv % | |
|---|---|---|---|
| event_name | |||
| login | 2493 | 927 | 99.89 |
| product_cart | 659 | 255 | 27.48 |
| product_page | 1331 | 523 | 56.36 |
| purchase | 640 | 256 | 27.59 |
Построю воронки событий для групп А и В, предварительно добавив столбец со всеми пользователями в каждую группу.
funnel_a = result_a.reset_index()
append_all_visitors_a = {'event_name': 'visit_site', 'users': test_all_a}
funnel_a = funnel_a[['event_name', 'users']].append(append_all_visitors_a, ignore_index=True)
funnel_a = funnel_a.sort_values(by='users', ascending=False)
funnel_b = result_b.reset_index()
append_all_visitors_b = {'event_name': 'visit_site', 'users': test_all_b}
funnel_b = funnel_b[['event_name', 'users']].append(append_all_visitors_b, ignore_index=True)
funnel_b = funnel_b.sort_values(by='users', ascending=False)
display(funnel_a, funnel_b)
| event_name | users | |
|---|---|---|
| 4 | visit_site | 2903 |
| 0 | login | 2082 |
| 2 | product_page | 1360 |
| 3 | purchase | 652 |
| 1 | product_cart | 631 |
| event_name | users | |
|---|---|---|
| 4 | visit_site | 2877 |
| 0 | login | 927 |
| 2 | product_page | 523 |
| 3 | purchase | 256 |
| 1 | product_cart | 255 |
Построение маркетинговой воронки.
steps = ['Посещение сайта', 'Регистрация', 'Просмотр карточки товара', 'Просмотры корзины', 'Покупка']
a = pd.DataFrame(dict(number=funnel_a['users'].reindex([4, 0, 2, 1, 3]).to_list(), stage=steps))
a['group'] = 'A'
b = pd.DataFrame(dict(number=funnel_b['users'].reindex([4, 0, 2, 1, 3]).to_list(), stage=steps))
b['group'] = 'B'
df = pd.concat([a, b], axis=0)
fig = px.funnel(df, x='number', y='stage', color='group', title='Воронка')
fig.show()
Анализируя полученные результаты можно сказать следующее:
При изначальном равномерном распределении количества участников теста, регистрацию в группе А прошли гораздо больше людей, поэтому количество событий на пользователя в выборках распределены неодинаково.
При построении маркетинговой воронки обнаруживаются некоторые несоответствия: людей, совершивших покупку, немного больше, чем тех, кто заходил в корзину. Это может быть связано с тем, что в корзине уже лежат отложенные товары и есть возможность произвести покупку без перехода на станицу корзины.
На этом этапе буду проводить проверку гипотез для долей в двух группах тестирования.
Если некоторая доля генеральной совокупности обладает признаком, а другая её часть — нет, об этой доле можно судить по выборке из генеральной совокупности. Т.к. речь тут идет о проверке гипотезы о равенстве долей, то я буду использовать z-тест.
Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез, а именно, уточнить критический уровень статистической значимости со стандартных 5%, я буду использовать поправку Бонферрони, т.е. разделю уровень значимости на число проверяемых гипотез (т.е. 3). при выбранном критическом уровне статистической значимости в 5%. Выбранный уровень 1,7%.
Нулевая гипотеза H₀:
Альтернативная гипотеза H₁:
Исходные данные:
print('Активных пользователей в группе A:', funnel_a.query('event_name == "login"')['users'].sum())
print('Активных пользователей в группе B:', funnel_b.query('event_name == "login"')['users'].sum())
print()
print('Просмотр карточек товаров в группе A:', funnel_a.query('event_name == "product_page"')['users'].sum())
print('Просмотр карточек товаров в группе B:', funnel_b.query('event_name == "product_page"')['users'].sum())
print()
print('Конверсия в просмотр карточек товаров в группе A:',
round(funnel_a.query('event_name == "product_page"')['users'].sum()
/ funnel_a.query('event_name == "login"')['users'].sum(), 2))
print('Конверсия в просмотр карточек товаров в группе B:',
round(funnel_b.query('event_name == "product_page"')['users'].sum()
/ funnel_b.query('event_name == "login"')['users'].sum(), 2))
Активных пользователей в группе A: 2082 Активных пользователей в группе B: 927 Просмотр карточек товаров в группе A: 1360 Просмотр карточек товаров в группе B: 523 Конверсия в просмотр карточек товаров в группе A: 0.65 Конверсия в просмотр карточек товаров в группе B: 0.56
Проверю, является ли эта разница в долях статистически значимой.
z_test(0.0017, 928, 2082, 523, 1360)
p-значение: 2.6973913398453675e-06 Отвергаем нулевую гипотезу: между долями есть значимая разница
Делаю вывод, что показатели конверсии в просмотр карточек в экспериментальной группе B ухудшились по сранению с группой A. И это различие является статистически значимым.
Нулевая гипотеза H₀:
Альтернативная гипотеза H₁:
Исходные данные:
print('Просмотр корзины товаров в группе A:', funnel_a.query('event_name == "product_cart"')['users'].sum())
print('Просмотр корзины товаров в группе B:', funnel_b.query('event_name == "product_cart"')['users'].sum())
print()
print('Конверсия в просмотр корзины в группе A:',
round(funnel_a.query('event_name == "product_cart"')['users'].sum()
/ funnel_a.query('event_name == "login"')['users'].sum(), 2))
print('Конверсия в просмотр корзины в группе B:',
round(funnel_b.query('event_name == "product_cart"')['users'].sum()
/ funnel_b.query('event_name == "login"')['users'].sum(), 2))
Просмотр корзины товаров в группе A: 631 Просмотр корзины товаров в группе B: 255 Конверсия в просмотр корзины в группе A: 0.3 Конверсия в просмотр корзины в группе B: 0.28
Проверю, является ли эта разница в долях статистически значимой.
z_test(0.0017, 928, 2082, 255, 631)
p-значение: 0.11580287250987387 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Делаю вывод, что ухудшение показателя конверсии в просмотр корзины в экспериментальной группе B не является статистически значимым.
Нулевая гипотеза H₀:
Альтернативная гипотеза H₁:
Исходные данные:
print('Покупки в группе A:', funnel_a.query('event_name == "purchase"')['users'].sum())
print('Покупки в группе B:', funnel_b.query('event_name == "purchase"')['users'].sum())
print()
print('Конверсия в покупку в группе A:',
round(funnel_a.query('event_name == "purchase"')['users'].sum()
/ funnel_a.query('event_name == "login"')['users'].sum(), 2))
print('Конверсия в покупку в группе B:',
round(funnel_b.query('event_name == "purchase"')['users'].sum()
/ funnel_b.query('event_name == "login"')['users'].sum(), 2))
Покупки в группе A: 652 Покупки в группе B: 256 Конверсия в покупку в группе A: 0.31 Конверсия в покупку в группе B: 0.28
Проверю, является ли эта разница в долях статистически значимой.
z_test(0.0017, 928, 2082, 256, 652)
p-значение: 0.03950712249997701 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Делаю вывод, что ухудшение показателя конверсии в покупку в экспериментальной группе B не является статистически значимым.
# Срезы
#funnel_a.loc[3, 'users']
Целью тестирования являлось тестирование изменений, связанных с внедрением улучшенной рекомендательной системы.
В соотвествии с ТЗ:
Характеристика теста:
Ожидания и результаты:
Прежде чем приступать к A/B-тестированию, необходимо соблюдение некоторых критериев успешности его проведения, а именно:
Критерий по количеству пользователей в группах был соблюден, однако в предоставленных данных присутствовали пользователи, участвовавшие в нескольких тестах одновременно, поэтому для чистоты эксперимента они были исключены. Также под вопросом стоит вопрос активности участника теста. В процессе анализа было выявлено, что больше половины участников в группе В окозалась неактивной и не совершила ни одного события, что могло исказить результаты проведения тестирования.
Не все заявленные пункты технического задания были выполненя, а именно:
Можно отметить также и то, что временной интервал проведения А/В-тестирования попал аж на 2 рекламные кампании - рождественскую и новогоднюю, здесь необходимо учитывать сезонный фактор, возможно, люди разъехались на каникулы и не смогли совершить события.
В любом случае, А/В-тест проведен. Результаты можно оценить следующим образом: Показатели конверсии в экспериментальной группе с новой платежной воронкой ухудшились. Статистически значимым является ухудшение показателей конверсии в просмотр карточек (-9%). Также ухудшились показатели конверсии в просмотр корзины и покупку (ухудшение обеих метрик на 3%).
Считаю результаты проведённого теста не совсем корректными, т.к. в предоставленных данных и в датах достаточно нюансов и несоблюдений критериев успешности его проведения.
Однако можно однозначно сказать, что новая платёжная воронка имеет отрицательный эффект по сравнению с исходной версией и внедрять ее на постоянную основу пока нецелесообразно, ее нужно доработать.
Также хочется отметить, что проводить А/В-тест в период проведения новогодних кампаний неэффективно, необходимо в будущем сроки тестирования выбирать таким образом, чтобы не пересекаться с такими значимыми событиями, т.к. это может повлиять на успешность таких тестов, исказив их результаты.